자연어로 작성된 명세서

  • 2012-01-01 (modified: 2025-07-17)

자연어는 생각보다 정교하지 못하다. 자연어로 작성하는 기획서나 명세서에는 여러 한계가 있다. 이 한계로 인한 병목은 AI 에이전트 활용이 일상화되면서 더 두드러지게 드러나기 시작한다.

문제점

생각한 걸 정확히 표현하기란 어려운 일이다. 설사 정확히 표현했다고 하더라도 그에 따른 귀결이 예상과는 다른 경우가 많다.

“What you write down doesn’t mean exactly what you think it means. And when it does, it doesn’t have the consequences you expected.” —Daniel Jackson, Software Abstractions: Logic, Language, and Analysis

이건 오래 전부터 있던 문제였지만, AI 에이전트에게 자연어로 지시하는 방식의 AI-인간 상호작용 루프에서의 병목을 정확히 잡아낸 문장이라고 생각한다. (위 책은 AI와 관련이 없으며 위 인용은 자연어에 대한 얘기도 아니지만 너무 찰떡같아서 인용했다.)

간단한 파일 시스템의 명세서를 작성하고 개선하는 사례를 통해 자연어로 쓰인 명세서의 한계를 (조금 과장되게) 살펴보려고 한다. Practical Alloy의 예시를 차용했다.

2011년 넥슨 개발자 컨퍼런스에서 발표했던 내용의 일부를 보강하여 정리한 글이다.

파일 시스템 명세

파일 시스템은 엔티티의 집합이다. 파일 엔티티에는 내용을 저장할 수 있다. 디렉토리 엔티티는 다른 파일을 담을 수 있다.

대부분의 사람은 이미 파일 시스템이 뭐고 파일이 뭐고 디렉토리가 뭔지 알고 있기 때문에 위와 같이 서술하면 대충 알아먹는다. 암묵적으로 공유되는 맥락 덕분이다. LLM도 유사한 맥락을 공유하고 있기 때문에 대체로 잘 알아듣는다. (하지만 “대체로”만 잘 알아듣기 때문에 문제가 생기곤 한다. 램프의 요정 지니에게 소원을 빌 때 조심해야 하는 이유)

암묵적으로 공유되는 맥락이 얼마나 많은지, 자연어로 기술된 명세에 얼마나 많은 정보가 생략되어 있는지, 암묵적인 맥락을 “다른 방식으로” 해석할 여지가 얼마나 많은지 등을 알아보기 위해, 위 명세를 극도로 엄밀하게 해석하면 어떻게 될까?

Alloy라는 모델링 언어로 위 명세를 최대한 그대로 표현해보면 아래와 같다. (Alloy 문법을 모르더라도 대충 읽고 느낌만 이해할 수 있으면 괜찮으니 지나치게 신경쓰지 않아도 된다.)

// 파일 시스템은 엔티티의 집합
sig Entity {}

// 파일에 담길 내용
sig Content {}

// 파일은 엔티티의 일종. 내용을 담을 수 있음
sig File in Entity { content: one Content }

// 디렉토리는 엔티티의 일종. 다른 파일을 담을 수 있음
sig Dir in Entity { children: lone File }

위 명세를 “실행”하여 파일 시스템의 사례를 보여달라고 지시하면 수많은 오류가 발견된다. 가장 눈에 띄는 오류는 파일이면서 동시에 디렉토리인 엔티티가 존재한다는 점이다. 왜냐하면 명세서에 파일이면서 동시에 디렉토리이면 안된다는 말이 없기 때문이다.

키워드 in 대신 extends를 쓰면 DirFile서로소(disjoint sets) 임을 표현할 수 있다.

sig Dir extends Entity { children: lone File }
sig File extends Entity { content: one Content }

다시 실행해보니 다른 문제가 발생하는데, 디렉토리도 아니고 파일도 아닌 엔티티가 존재한다는 문제다. 모든 엔티티는 파일 또는 디렉토리라고 쓰지 않았기 때문이다. abstract 키워드를 쓰면 파티션(partition) 개념을 표현할 수 있다.

abstract sig Entity {}

다음 문제: 파일에 속하지 않은 “내용(content)“이 존재한다. 파일에 내용이 담길 수 있다고만 했지, 파일 없이 내용만 따로 존재해서는 안된다는 말을 안했기 때문이다. fact를 추가하여 해당 제약을 명시하자.

fact 콘텐츠는_반드시_파일에_속해야_한다 {
  all c: Content | c in File.content
}

다음 문제: 디렉토리에 속하지 않은 파일이 존재한다. 모든 파일은 디렉토리에 속해야만 한다고 말하지 않았기 때문이다. fact를 추가해야 한다.

fact 파일은_반드시_디렉토리에_속해야_한다 {
  all f: File | f in Dir.children
}

다음 문제: 여러 디렉토리에 동시에 속하는 파일이 존재한다. 파일이 하나의 디렉토리에만 속해야 한다고 말하지 않았기 때문이다. fact를 수정하자.

fact 파일은_반드시_하나의_디렉토리에_속해야_한다 {
  all f: File | #children.f = 1
}

다음 문제: 디렉토리 안에 파일이 없거나 한 개만 있을 수 있다. 여러 파일을 담을 수 있다고 말하지 않았기 때문이다. 0개 또는 1개를 뜻하는 한정사인 lone을 0개 이상을 뜻하는 한정사인 set으로 바꾸자.

sig Dir extends Entity { children: set File }

다음 문제: 디렉토리 안에 디렉토리가 담길 수 없다. 왜냐하면 명세에 디렉토리 안에 “파일”이 담긴다고 썼기 때문이다. 수정하자.

sig Dir extends Entity { children: set Entity }

다음 문제: 위와 같이 재귀적 관계를 추가했더니 자기 스스로를 포함하는 디렉토리가 생긴다. 순환(cycle) 방지를 위한 제약은 이행적 폐쇄(transitive closure) 개념을 활용하여 쉽게 표현할 수 있다.

fact 순환은_존재하지_않는다 {
  no d: Dir | d in d.^children
}

다음 문제: 루트 디렉토리가 여러개다. 루트 디렉토리는 반드시 하나만 있어야 한다고 적자. 이 말도 사실 명확하지 않다. “반드시 하나만 있어야 한다”는 말은 “하나는 반드시 있어야 한다”를 함축하지 않는다. 우리가 원하는 걸 정확히 쓰자면, “루트 디렉토리가 정확히 1개 있어야 한다”이다.

one sig Root extends Dir {}

fact 루트를_제외한_모든_객체는_루트로부터_도달_가능하다 {
  Root.^children = Entity - Root
}

다음 문제: 여러 디렉토리에 동시에 속하는 디렉토리가 존재한다. 아까 “파일은 반드시 하나의 디렉토리에 속해야 한다”라는 팩트를 만들었는데 이걸 조금 수정하자.

fact 엔티티는_반드시_하나의_디렉토리에_속해야_한다 {
  all e: Entity | one children.e
}

이렇게 고쳤더니 이러한 파일 시스템은 존재할 수 없다는 오류가 발생한다. 왜냐하면 명세에 모순이 있기 때문인데, 루트 디렉토리에는 상위 디렉토리가 있을 수 없는 반면(루트가 최상위이므로), 모든 엔티티(루트는 엔티티의 일종이다)는 반드시 하나의 디렉토리에 속해야 한다고도 했기 때문이다. “루트를 제외한 모든 엔티티”라고 고쳐야 한다.

fact 루트를_제외한_엔티티는_반드시_하나의_디렉토리에_속해야_한다 {
  all e: Entity - Root | one children.e
}

자연어로 번역하기

원래의 자연어 명세는 이랬다:

파일 시스템은 엔티티의 집합이다. 파일 엔티티에는 내용을 저장할 수 있다. 디렉토리 엔티티는 다른 파일을 담을 수 있다.

Alloy로 작성된 현재의 명세서는 다음과 같다.

abstract sig Entity {}

sig Dir extends Entity { children: set Entity }

sig Content {}

sig File extends Entity { content: one Content }

one sig Root extends Dir {}

fact 루트를_제외한_모든_객체는_루트로부터_도달_가능하다 {
  Root.^children = Entity - Root
}

fact 콘텐츠는_반드시_파일에_속해야_한다 {
  all c: Content | c in File.content
}

fact 루트를_제외한_엔티티는_반드시_하나의_디렉토리에_속해야_한다 {
  all e: Entity - Root | one children.e
}

fact 순환은_존재하지_않는다 {
  no d: Dir | d in d.^children
}

이걸 자연어로 번역해보자.

파일 시스템은 엔티티의 집합이다. 엔티티는 파일 또는 디렉토리 중 하나이며 파일이면서 동시에 디렉토리인 엔티티는 존재하지 않는다. 파일에는 언제나 1개의 내용이 담기며, 파일 없이 내용만 존재할 수는 없다. 디렉토리는 비어있거나 다른 엔티티를 1개 이상 포함할 수 있다. 파일 시스템에는 반드시 단일한 루트 디렉토리가 있어야 하며, 모든 엔티티는 루트로부터 도달 가능해야 한다. 디렉토리는 자기 자신을 재귀적으로 다시 포함할 수 없다.

개선

두가지 개선점이 떠오른다.

첫째, 모델링 대상을 그대로 복제한 건 모델이 아니다. 좋은 모델에는 중요한 요소만 선별적으로 담겨야 한다. “파일에 콘텐츠가 담긴다”는 점은 이 모델의 핵심이 아니니 생략하는 게 좋겠다.

둘째, 명세서의 문제점을 하나하나 땜빵식으로 돌려막는 과정에서 여러 제약(fact)들이 마구 추가됐는데, 사실은 두 개의 제약만 있으면 나머지는 논리적으로 딸려온다(implied). 따라서 불필요한 제약은 제거하는 게 좋겠다.

fact 루트를_제외한_모든_객체는_루트로부터_도달_가능하다 {
  Root.^children = Entity - Root
}

fact 부모는_최대_1개이다 {
  all f: Entity | lone children.f
}

위 두 개의 제약만 남겨놓고 다른 제약들이 실제로 논리적으로 유도되는지 살펴보기 위해 단언(assert)을 검사(check)해볼 수 있다.

assert 루트를_제외한_모든_객체는_어딘가에_속한다 {
  all e: Entity - Root | one children.e
}

assert 루트는_어디에도_속하지_않는다 {
  no children.Root
}

assert 순환이_존재하지_않는다 {
  no d: Entity | d in d.^children
}

check 루트를_제외한_모든_객체는_어딘가에_속한다
check 루트는_어디에도_속하지_않는다
check 순환이_존재하지_않는다

실행해보면 다음과 같은 메시지를 볼 수 있다.

3 commands were executed. The results are:
#1: No counterexample found. 루트를_제외한_모든_객체는_어딘가에_속한다 may be valid.
#2: No counterexample found. 루트는_어디에도_속하지_않는다 may be valid.
#3: No counterexample found. 순환이_존재하지_않는다 may be valid.

맥락 상 중요한 얘기는 아니지만 “is valid”가 아니라 “may be valid”인 이유가 궁금한 사람은 좁은 범위 가설을 참고.

사람의 머리로 할 일인가

약간 과장된 (예: 일부러 지나치게 모호하게 쓴 명세) 사례지만, 이 예시를 통해 얻을 수 있는 교훈들을 꼽아보자. 명세서를 잘 작성하기 위해 꼭 필요하지만 자연어의 한계로 인해 극복하기 쉽지 않은 일들에는 무엇이 있을까?

  • 명세를 간결하면서도 명확하게 적기
  • 명세에 내적 모순이 없는지 빠르게 확인하기
  • 두 표현이 서로 논리적 동치인지 빠르게 확인하기
  • 명세에 의해 암묵적으로 유도되는 함축된 명제들이 내가 기대했던 바와 일치하는지 빠르게 확인하기

형식화된 모델링 언어를 쓴다고 해서 곧바로 문제가 다 해결되는 건 아니지만, 문제를 서서히 개선할 수 있는 좋은 수단 중 하나라고 생각한다. 작성된 명세서(결과물)보다 더 중요한 건 명세서를 작성하는 과정에서의 명확한 사고(과정)인데, 이 또한 엄밀한 언어를 쓰고 빠르게 피드백을 받는 과정에서 서서히 개선될 수 있다고 생각한다.

다음 단계의 병목

현대 사회에서 수많은 일정을 관리하려면 일정 관리 프로그램이 필요하다. 복잡한 계산을 하려면 공학계산기나 스프레드시트를 쓴다. 명세서나 기획서 작성도 대단히 복잡한 일이라서 생각의 과정을 지원해줄 도구가 필요하다. 하지만 우리가 기획을 할 때 쓰는 도구는 (대부분의 경우) 파워포인트나 피그마다. 이건 마치 복잡한 계산을 하는 사람이 계산기는 안쓰고 수식을 예쁘게 그려주는 도구(예: LaTeX) 정도만 쓰는 상황이랑 비슷하다.

소프트웨어 개발 공정에 있어서 지금까지는 프로그래밍이 주요 병목이었기 때문에 이 문제가 잘 드러나지 않았지만, 에이전트 기반 코딩 등으로 인해 이 병목이 제거되기 시작하면 다음 단계의 병목은 기획이 될 가능성이 크다고 생각한다.

기획자가 Alloy를 배워야 하나

분야에 따라 그럴 수도 있겠지만 LLM 덕분에 자연어 기반 명세를 Alloy 코드로 변환해주기가 쉬워졌으니 꼭 모두가 모델링 언어를 배워야 한다고 생각하지는 않는다. 예를 들면 아래와 같은 반자동 변환 절차를 생각해볼 수 있다.

  1. 인간이 자연어로 명세를 입력한다.
  2. LLM이 자연어 명세의 불명확한 부분을 자연어로 인간에게 설명해주고, 예시들을 보여주며 인간의 원래 의도를 더 정교하게 파악하며 Alloy 코드를 점진적으로 다듬는다.
  3. LLM이 다양한 사례(명세서에 따르면 벌어져야 할 일이 실제로 벌어질 수 있는지, 명세서에 따르면 벌어지지 말아야 할 일이 실제로 벌어지지 않는지)를 생성하여 인간에게 보여주고 검증을 받는다.

이런 프로세스에 의해 명세서 작성을 도와주는 도구가 있다면 사고를 가다듬는 훈련에도 도움이 될 것 같다. 사고를 가다듬는 일은 비단 명세서 작성 뿐 아니라 AI-인간 상호작용 루프에서의 병목을 줄이기 위해 일반적으로 필요한 일이라고 본다.

2025 © ak